Explorez les __slots__ de Python pour réduire considérablement l'utilisation de la mémoire et augmenter la vitesse d'accès aux attributs. Un guide complet avec des benchmarks, des compromis et des bonnes pratiques.
Les __slots__ de Python : Exploration approfondie de l'optimisation de la mémoire et de la vitesse des attributs
Dans le monde du développement logiciel, la performance est primordiale. Pour les développeurs Python, cela implique souvent un équilibre délicat entre l'incroyable flexibilité du langage et le besoin d'efficacité des ressources. L'un des défis les plus courants, en particulier dans les applications gourmandes en données, est la gestion de l'utilisation de la mémoire. Lorsque vous créez des millions, voire des milliards, de petits objets, chaque octet compte.
C'est là qu'une fonctionnalité moins connue mais puissante de Python entre en jeu : __slots__
. Elle est souvent saluée comme une solution miracle pour l'optimisation de la mémoire, mais sa véritable nature est plus nuancée. S'agit-il simplement d'économiser de la mémoire ? Votre code est-il vraiment plus rapide ? Et quels sont les coûts cachés de son utilisation ?
Ce guide complet vous emmènera dans une exploration approfondie des __slots__
de Python. Nous allons disséquer le fonctionnement interne des objets Python standard, évaluer l'impact réel des __slots__
sur la mémoire et la vitesse, explorer ses complexités et ses compromis surprenants, et fournir un cadre clair pour décider quand - et quand ne pas - utiliser cet outil d'optimisation puissant.
Le comportement par défaut : Comment les objets Python stockent les attributs avec `__dict__`
Avant de pouvoir apprécier ce que font les __slots__
, nous devons d'abord comprendre ce qu'elles remplacent. Par défaut, chaque instance d'une classe personnalisée en Python possède un attribut spécial appelé __dict__
. Il s'agit, littéralement, d'un dictionnaire qui stocke tous les attributs de l'instance.
Prenons un exemple simple : une classe pour représenter un point 2D.
import sys
class Point2D:
def __init__(self, x, y):
self.x = x
self.y = y
# Créer une instance
p1 = Point2D(10, 20)
# Les attributs sont stockés dans __dict__
print(p1.__dict__) # Sortie : {'x': 10, 'y': 20}
# Vérifions la taille de __dict__ lui-même
print(f"Taille du __dict__ de l'instance Point2D : {sys.getsizeof(p1.__dict__)} octets")
La sortie peut varier légèrement en fonction de votre version de Python et de l'architecture de votre système (par exemple, 64 octets sur Python 3.10+ pour un petit dictionnaire), mais le principal enseignement est que ce dictionnaire a sa propre empreinte mémoire, distincte de l'objet instance lui-même et des valeurs qu'il contient.
Le pouvoir et le prix de la flexibilité
Cette approche __dict__
est la pierre angulaire du dynamisme de Python. Elle vous permet d'ajouter de nouveaux attributs à une instance à tout moment, une pratique souvent appelée "monkey-patching" :
# Ajouter un nouvel attribut à la volée
p1.z = 30
print(p1.__dict__) # Sortie : {'x': 10, 'y': 20, 'z': 30}
Cette flexibilité est fantastique pour le développement rapide et certains modèles de programmation. Cependant, elle a un coût : la surcharge mémoire.
Les dictionnaires en Python sont hautement optimisés, mais ils sont intrinsèquement plus complexes que les structures de données plus simples. Ils doivent maintenir une table de hachage pour fournir des recherches de clés rapides, ce qui nécessite une mémoire supplémentaire pour gérer les collisions de hachage potentielles et permettre un redimensionnement efficace. Lorsque vous créez des millions d'instances de Point2D
, chacune portant son propre __dict__
, cette surcharge de mémoire s'accumule rapidement.
Imaginez une application traitant un modèle 3D avec 10 millions de sommets. Si chaque objet de sommet a un __dict__
de 64 octets, cela représente 640 mégaoctets de mémoire consommée uniquement par les dictionnaires, avant même de tenir compte des valeurs entières ou flottantes réelles qu'ils stockent ! C'est le problème que __slots__
a été conçu pour résoudre.
Présentation de `__slots__` : L'alternative permettant d'économiser de la mémoire
__slots__
est une variable de classe qui vous permet de déclarer explicitement les attributs qu'une instance aura. En définissant __slots__
, vous dites essentiellement à Python : "Les instances de cette classe auront uniquement ces attributs spécifiques. Vous n'avez pas besoin de créer un __dict__
pour eux."
Au lieu d'un dictionnaire, Python réserve une quantité fixe d'espace mémoire pour l'instance, juste assez pour stocker des pointeurs vers les valeurs des attributs déclarés, un peu comme une structure C ou un tuple.
Refactorisons notre classe Point2D
pour utiliser __slots__
.
class SlottedPoint2D:
# Déclarer les attributs de l'instance
# Il peut s'agir d'un tuple (le plus courant), d'une liste ou de tout itérable de chaînes.
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
En apparence, il se ressemble presque. Mais en interne, tout a changé. Le __dict__
a disparu.
p_slotted = SlottedPoint2D(10, 20)
# Tenter d'accéder à __dict__ lèvera une erreur
try:
print(p_slotted.__dict__)
except AttributeError as e:
print(e) # Sortie : L'objet 'SlottedPoint2D' n'a pas d'attribut '__dict__'
Évaluation comparative des économies de mémoire
Le véritable moment "wow" survient lorsque nous comparons l'utilisation de la mémoire. Pour ce faire avec précision, nous devons comprendre comment la taille des objets est mesurée. sys.getsizeof()
indique la taille de base d'un objet, mais pas la taille des éléments auxquels il fait référence, comme le __dict__
.
import sys
# --- Classe normale ---
class Point2D:
def __init__(self, x, y):
self.x = x
self.y = y
# --- Classe Ă slots ---
class SlottedPoint2D:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# Créer une instance de chaque pour comparer
p_normal = Point2D(1, 2)
p_slotted = SlottedPoint2D(1, 2)
# La taille de l'instance Ă slots est beaucoup plus petite
# Il s'agit généralement de la taille de l'objet de base plus un pointeur pour chaque slot.
size_slotted = sys.getsizeof(p_slotted)
# La taille de l'instance normale inclut sa taille de base et un pointeur vers son __dict__.
# La taille totale est la taille de l'instance + la taille de __dict__.
size_normal = sys.getsizeof(p_normal) + sys.getsizeof(p_normal.__dict__)
print(f"Taille d'une seule instance SlottedPoint2D : {size_slotted} octets")
print(f"Empreinte mémoire totale d'une seule instance Point2D : {size_normal} octets")
# Voyons maintenant l'impact à l'échelle
NUM_INSTANCES = 1_000_000
# Dans une application réelle, vous utiliseriez un outil comme memory_profiler
# pour mesurer l'utilisation totale de la mémoire du processus.
# Nous pouvons estimer les économies en fonction de notre calcul d'instance unique.
size_diff_per_instance = size_normal - size_slotted
total_memory_saved = size_diff_per_instance * NUM_INSTANCES
print(f"\nCréation de {NUM_INSTANCES:,} instances...")
print(f"Mémoire économisée par instance en utilisant __slots__ : {size_diff_per_instance} octets")
print(f"Estimation de la mémoire totale économisée : {total_memory_saved / (1024*1024):.2f} Mo")
Sur un système 64 bits typique, vous pouvez vous attendre à une économie de mémoire de 40 à 50 % par instance. Un objet normal peut prendre 16 octets pour sa base + 8 octets pour le pointeur __dict__
+ 64 octets pour le __dict__
vide, soit un total de 88 octets. Un objet à slots avec deux attributs ne peut prendre que 32 octets. Cette différence d'environ 56 octets par instance se traduit par 56 Mo économisés pour un million d'instances. Il ne s'agit pas d'une micro-optimisation ; c'est un changement fondamental qui peut rendre une application infaisable, réalisable.
La deuxième promesse : Un accès plus rapide aux attributs
Au-delà des économies de mémoire, __slots__
est également vanté pour améliorer les performances. La théorie est solide : accéder à une valeur à partir d'un décalage mémoire fixe (comme un index de tableau) est plus rapide que d'effectuer une recherche de hachage dans un dictionnaire.
- Accès
__dict__
:obj.x
implique une recherche de dictionnaire pour la clé'x'
. - Accès
__slots__
:obj.x
implique un accès direct à la mémoire vers un emplacement spécifique.
Mais combien plus rapide est-ce en pratique ? Utilisons le module intégré timeit
de Python pour le découvrir.
import timeit
# Code de configuration à exécuter une fois avant le chronométrage
SETUP_CODE = """
class Point2D:
def __init__(self, x, y):
self.x = x
self.y = y
class SlottedPoint2D:
__slots__ = 'x', 'y'
def __init__(self, x, y):
self.x = x
self.y = y
p_normal = Point2D(1, 2)
p_slotted = SlottedPoint2D(1, 2)
"""
# Tester la lecture des attributs
read_normal = timeit.timeit("p_normal.x", setup=SETUP_CODE, number=10_000_000)
read_slotted = timeit.timeit("p_slotted.x", setup=SETUP_CODE, number=10_000_000)
print("--- Lecture des attributs ---")
print(f"Temps pour l'accès à __dict__ : {read_normal:.4f} secondes")
print(f"Temps pour l'accès à __slots__ : {read_slotted:.4f} secondes")
speedup = (read_normal - read_slotted) / read_normal * 100
print(f"Accélération : {speedup:.2f}%")
print("\n--- Écriture des attributs ---")
# Tester l'écriture des attributs
write_normal = timeit.timeit("p_normal.x = 3", setup=SETUP_CODE, number=10_000_000)
write_slotted = timeit.timeit("p_slotted.x = 3", setup=SETUP_CODE, number=10_000_000)
print(f"Temps pour l'accès à __dict__ : {write_normal:.4f} secondes")
print(f"Temps pour l'accès à __slots__ : {write_slotted:.4f} secondes")
speedup = (write_normal - write_slotted) / write_normal * 100
print(f"Accélération : {speedup:.2f}%")
Les résultats montreront que __slots__
est en effet plus rapide, mais l'amélioration se situe généralement entre 10 et 20 %. Bien que non négligeable, c'est beaucoup moins spectaculaire que les économies de mémoire.
Point clé à retenir : Utilisez __slots__
principalement pour l'optimisation de la mémoire. Considérez l'amélioration de la vitesse comme un bonus bienvenu, mais secondaire. Le gain de performance est plus pertinent dans les boucles étroites au sein d'algorithmes gourmands en calcul où l'accès aux attributs se produit des millions de fois.
Les compromis et les "pièges" : Ce que vous perdez avec `__slots__`
__slots__
n'est pas une solution miracle. Les gains de performance se font au détriment de la flexibilité et introduisent certaines complexités, notamment en ce qui concerne l'héritage. Comprendre ces compromis est essentiel pour utiliser __slots__
efficacement.
1. Perte d'attributs dynamiques
C'est la conséquence la plus importante. En prédéfinissant les attributs, vous perdez la possibilité d'en ajouter de nouveaux lors de l'exécution.
p_slotted = SlottedPoint2D(10, 20)
# Cela fonctionne bien
p_slotted.x = 100
# Cela échouera
try:
p_slotted.z = 30 # 'z' n'était pas dans __slots__
except AttributeError as e:
print(e) # Sortie : L'objet 'SlottedPoint2D' n'a pas d'attribut 'z'
Ce comportement peut être une fonctionnalité, pas un bug. Il impose un modèle d'objet plus strict, empêchant la création accidentelle d'attributs et rendant la "forme" de la classe plus prévisible. Cependant, si votre conception repose sur l'attribution dynamique d'attributs, __slots__
est un non-démarreur.
2. L'absence de `__dict__` et `__weakref__`
Comme nous l'avons vu, __slots__
empêche la création de __dict__
. Cela peut être problématique si vous devez travailler avec des bibliothèques ou des outils qui reposent sur l'introspection via __dict__
.
De mĂŞme, __slots__
empêche également la création automatique de __weakref__
, un attribut qui est nécessaire pour qu'un objet soit faiblement référençable. Les références faibles sont un outil avancé de gestion de la mémoire utilisé pour suivre les objets sans les empêcher d'être collectés par le ramasse-miettes.
La solution : Vous pouvez inclure explicitement '__dict__'
et '__weakref__'
dans votre définition __slots__
si vous en avez besoin.
class HybridSlottedPoint:
# Nous obtenons des économies de mémoire pour x et y, mais nous avons toujours __dict__ et __weakref__
__slots__ = ('x', 'y', '__dict__', '__weakref__')
def __init__(self, x, y):
self.x = x
self.y = y
p_hybrid = HybridSlottedPoint(5, 10)
p_hybrid.z = 20 # Cela fonctionne maintenant, car __dict__ est présent !
print(p_hybrid.__dict__) # Sortie : {'z': 20}
import weakref
w_ref = weakref.ref(p_hybrid) # Cela fonctionne également maintenant
print(w_ref)
L'ajout de '__dict__'
vous donne un modèle hybride. Les attributs à slots (x
, y
) sont toujours gérés efficacement, tandis que tous les autres attributs sont placés dans le __dict__
. Cela annule une partie des économies de mémoire, mais peut être un compromis utile pour conserver la flexibilité tout en optimisant les attributs les plus courants.
3. Les complexités de l'héritage
C'est lĂ que __slots__
peut devenir délicat. Son comportement change en fonction de la manière dont les classes parent et enfant sont définies.
Héritage simple
-
Si une classe parent a
__slots__
mais que l'enfant n'en a pas : La classe enfant héritera du comportement à slots pour les attributs du parent, mais aura également son propre__dict__
. Cela signifie que les instances de la classe enfant seront plus grandes que les instances du parent.class SlottedBase: __slots__ = ('a',) class DictChild(SlottedBase): # Aucun __slots__ défini ici def __init__(self): self.a = 1 self.b = 2 # 'b' sera stocké dans __dict__ c = DictChild() print(f"L'enfant a __dict__ : {hasattr(c, '__dict__')}") # Sortie : True print(c.__dict__) # Sortie : {'b': 2}
-
Si les classes parent et enfant définissent toutes les deux
__slots__
: La classe enfant n'aura pas de__dict__
. Son__slots__
effectif sera la combinaison de son propre__slots__
et du__slots__
de son parent.class SlottedBase: __slots__ = ('a',) class SlottedChild(SlottedBase): __slots__ = ('b',) # Les slots effectifs sont ('a', 'b') def __init__(self): self.a = 1 self.b = 2 sc = SlottedChild() print(f"L'enfant a __dict__ : {hasattr(sc, '__dict__')}") # Sortie : False try: sc.c = 3 # Lève AttributeError except AttributeError as e: print(e)
__slots__
d'un parent contient un attribut également répertorié dans le__slots__
de l'enfant, il est redondant, mais généralement inoffensif.
Héritage multiple
L'héritage multiple avec __slots__
est un champ de mines. Les règles sont strictes et peuvent entraîner des erreurs inattendues.
-
La règle fondamentale : Pour qu'une classe enfant utilise
__slots__
efficacement (c'est-Ă -dire sans__dict__
), toutes ses classes parent doivent également avoir__slots__
. Si mĂŞme une seule classe parent n'a pas de__slots__
(et a donc__dict__
), la classe enfant aura également un__dict__
. -
Le piège `TypeError` : Une classe enfant ne peut pas hériter de plusieurs classes parent qui ont toutes les deux des
__slots__
non vides.class SlotParentA: __slots__ = ('x',) class SlotParentB: __slots__ = ('y',) try: class ProblemChild(SlotParentA, SlotParentB): pass except TypeError as e: print(e) # Sortie : plusieurs bases ont un conflit de disposition d'instance
Le verdict : Quand et quand ne pas utiliser `__slots__`
Avec une compréhension claire des avantages et des inconvénients, nous pouvons établir un cadre de prise de décision pratique.
Feux verts : Utilisez `__slots__` quand...
- Vous créez un nombre massif d'instances. C'est le cas d'utilisation principal. Si vous traitez des millions d'objets, les économies de mémoire peuvent faire la différence entre une application qui s'exécute et une application qui plante.
-
Les attributs de l'objet sont fixes et connus Ă l'avance.
__slots__
est parfait pour les structures de données, les enregistrements ou les objets de données simples dont la "forme" ne change pas. - Vous vous trouvez dans un environnement où la mémoire est limitée. Cela inclut les appareils IoT, les applications mobiles ou les serveurs à haute densité où chaque mégaoctet est précieux.
-
Vous optimisez un goulot d'étranglement de performance. Si le profilage montre que l'accès aux attributs dans une boucle étroite est un ralentissement important, le modeste gain de vitesse de
__slots__
peut valoir la peine.
Exemples courants :
- Nœuds dans une grande structure de graphe ou d'arbre.
- Particules dans une simulation physique.
- Objets représentant des lignes d'une grande requête de base de données.
- Objets d'événement ou de message dans un système à haut débit.
Feux rouges : Évitez `__slots__` quand...
-
La flexibilité est essentielle. Si votre classe est conçue pour un usage général ou si vous comptez sur l'ajout d'attributs dynamiquement (monkey-patching), restez avec le
__dict__
par défaut. -
Votre classe fait partie d'une API publique destinée à être sous-classée par d'autres. Imposer
__slots__
à une classe de base impose des contraintes à toutes les classes enfants, ce qui peut être une surprise désagréable pour vos utilisateurs. -
Vous ne créez pas suffisamment d'instances pour que cela ait de l'importance. Si vous n'avez que quelques centaines ou milliers d'instances, les économies de mémoire seront négligeables. L'application de
__slots__
ici est une optimisation prématurée qui ajoute de la complexité sans gain réel. -
Vous traitez des hiérarchies d'héritage multiple complexes. Les restrictions de
TypeError
peuvent rendre__slots__
plus problématique que ce qu'elle ne vaut dans ces scénarios.
Alternatives modernes : `__slots__` est-il toujours le meilleur choix ?
L'écosystème de Python a évolué et __slots__
n'est plus le seul outil pour créer des objets légers. Pour le code Python moderne, vous devriez envisager ces excellentes alternatives.
`collections.namedtuple` et `typing.NamedTuple`
Les namedtuples sont une fonction de fabrique pour créer des sous-classes de tuple avec des champs nommés. Elles sont incroyablement efficaces en mémoire (encore plus que les objets à slots car ce sont des tuples en dessous) et, surtout, immuables.
from typing import NamedTuple
# Crée une classe immuable avec des indications de type
class Point(NamedTuple):
x: int
y: int
p = Point(10, 20)
print(p.x) # 10
try:
p.x = 30 # Lève AttributeError : impossible de définir l'attribut
except AttributeError as e:
print(e)
Si vous avez besoin d'un conteneur de données immuable, une NamedTuple
est souvent un choix meilleur et plus simple qu'une classe Ă slots.
Le meilleur des deux mondes : `@dataclass(slots=True)`
Introduites dans Python 3.7 et améliorées dans Python 3.10, les dataclasses changent la donne. Elles génèrent automatiquement des méthodes comme __init__
, __repr__
et __eq__
, réduisant considérablement le code passe-partout.
De manière critique, le décorateur @dataclass
a un argument slots
(disponible depuis Python 3.10 ; pour Python 3.8-3.9, une bibliothèque tierce est nécessaire pour la même commodité). Lorsque vous définissez slots=True
, la dataclass générera automatiquement un attribut __slots__
basé sur les champs définis.
from dataclasses import dataclass
@dataclass(slots=True)
class DataPoint:
x: int
y: int
dp = DataPoint(10, 20)
print(dp) # Sortie : DataPoint(x=10, y=20) - belle repr gratuite !
print(hasattr(dp, '__dict__')) # Sortie : False - les slots sont activés !
Cette approche vous offre le meilleur de tous les mondes :
- Lisibilité et concision : Beaucoup moins de code passe-partout qu'une définition de classe manuelle.
- Commodité : Les méthodes spéciales auto-générées vous évitent d'écrire du code passe-partout courant.
- Performance : Les avantages complets en matière de mémoire et de vitesse de
__slots__
. - Sécurité de type : S'intègre parfaitement à l'écosystème de typage de Python.
Pour le nouveau code écrit en Python 3.10+, `@dataclass(slots=True)` devrait être votre choix par défaut pour créer des classes simples, mutables, économes en mémoire et contenant des données.
Conclusion : Un outil puissant pour un travail spécifique
__slots__
témoigne de la philosophie de conception de Python qui consiste à fournir des outils puissants aux développeurs qui ont besoin de repousser les limites de la performance. Ce n'est pas une fonctionnalité à utiliser sans discernement, mais plutôt un instrument tranchant et précis pour résoudre un problème spécifique et courant : le coût élevé en mémoire de nombreux petits objets.
Récapitulons les vérités essentielles sur __slots__
:
- Son principal avantage est une réduction significative de l'utilisation de la mémoire, réduisant souvent la taille des instances de 40 à 50 %. C'est sa fonctionnalité phare.
- Il offre une augmentation de vitesse secondaire, plus modeste pour l'accès aux attributs, généralement d'environ 10 à 20 %.
- Le principal compromis est la perte d'attribution dynamique d'attributs, imposant une structure d'objet rigide.
- Il introduit de la complexité avec l'héritage, nécessitant une conception soignée, en particulier dans les scénarios d'héritage multiple.
-
Dans Python moderne, `@dataclass(slots=True)` est souvent une alternative supérieure et plus pratique, combinant les avantages de
__slots__
avec l'élégance des dataclasses.
La règle d'or de l'optimisation s'applique ici : profilez d'abord. N'éparpillez pas __slots__
dans votre code en espérant une accélération magique. Utilisez des outils de profilage de la mémoire pour identifier les objets qui consomment le plus de mémoire. Si vous trouvez une classe qui est instanciée des millions de fois et qui est un gouffre de mémoire important, alors - et seulement alors - il est temps de recourir à __slots__
. En comprenant sa puissance et ses dangers, vous pouvez l'utiliser efficacement pour créer des applications Python plus efficaces et évolutives pour un public mondial.